--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit b8b40bcf163627e2f64cfea5887d95f93891e985
Parents : 280be74
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-06-19T10:47:56-05:00
feat(messages): add cancel send functionality for outbound messages, including UI updates and localization support
Changes
13 files changed, 201 insertions(+), 15 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index 4c16e7cb..29c95d78 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -305,6 +305,14 @@
:class="cv.outboundExpandedActionsShellClass(entry.items[0])"
>
<div class="flex items-center gap-2">
+ <button
+ v-if="cv.canCancelOutboundSend(entry.items[0])"
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-600 transition-colors"
+ @click.stop="cv.cancelSendingMessage(entry.items[0])"
+ >
+ {{ $t("messages.cancel_send") }}
+ </button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-blue-600 transition-colors"
@@ -395,7 +403,7 @@
v-if="isAnimatedRasterType(chatItem.lxmf_message.fields?.image?.image_type)"
:src="cv.pendingOutboundImageSrc(chatItem)"
img-class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
- @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
+ @click.stop="cv.onOutboundImageClick(chatItem)"
/>
<img
v-else
@@ -404,7 +412,7 @@
decoding="async"
class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
alt=""
- @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
+ @click.stop="cv.onOutboundImageClick(chatItem)"
/>
</template>
<div
@@ -431,6 +439,21 @@
:elevated="chatItem.is_outbound && cv.showOutboundTransferProgress(chatItem.lxmf_message)"
/>
</div>
+ <div
+ v-if="chatItem.is_actions_expanded && cv.canCancelOutboundSend(chatItem)"
+ class="border-t px-4 py-2.5 rounded-b-2xl rounded-t-md w-full max-w-[min(280px,85vw)] mb-1.5"
+ :class="[chatItem.is_outbound ? 'ml-auto' : 'mr-auto', cv.outboundExpandedActionsShellClass(chatItem)]"
+ >
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-600 transition-colors"
+ @click.stop="cv.cancelSendingMessage(chatItem)"
+ >
+ {{ $t("messages.cancel_send") }}
+ </button>
+ </div>
+ </div>
<!-- image-only: inline timestamp overlay (no bubble) -->
<div
v-if="cv.isImageOnlyMessage(chatItem) && (entry.showTimestamp !== false || chatItem.is_outbound)"
@@ -1174,6 +1197,14 @@
:class="cv.outboundExpandedActionsShellClass(chatItem)"
>
<div class="flex items-center gap-2">
+ <button
+ v-if="cv.canCancelOutboundSend(chatItem)"
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-600 transition-colors"
+ @click.stop="cv.cancelSendingMessage(chatItem)"
+ >
+ {{ $t("messages.cancel_send") }}
+ </button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-blue-600 transition-colors"
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 2ff46185..5177f331 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1035,6 +1035,14 @@
<MaterialDesignIcon icon-name="file-gif-box" class="size-4 text-pink-500" />
{{ $t("gifs.save_to_library") }}
</ContextMenuItem>
+ <ContextMenuItem
+ v-if="canCancelOutboundSend(messageContextMenu.chatItem)"
+ item-class="text-amber-600 dark:text-amber-400"
+ @click="cancelSendingMessage(messageContextMenu.chatItem)"
+ >
+ <MaterialDesignIcon icon-name="close-circle-outline" class="size-4" />
+ {{ $t("messages.cancel_send") }}
+ </ContextMenuItem>
<ContextMenuItem
v-if="
messageContextMenu.chatItem?.is_outbound &&
@@ -3944,6 +3952,16 @@ export default {
chatItem.is_actions_expanded = false;
}
},
+ onOutboundImageClick(chatItem) {
+ if (this.canCancelOutboundSend(chatItem)) {
+ this.onChatItemClick(chatItem);
+ return;
+ }
+ const src = this.pendingOutboundImageSrc(chatItem);
+ if (src) {
+ this.openImage(src);
+ }
+ },
copyableMessagePlainText(chatItem) {
const raw = chatItem?.lxmf_message?.content;
if (typeof raw !== "string") {
@@ -4683,6 +4701,15 @@ export default {
}
return ["outbound", "sending", "generating"].includes(m.state);
},
+ canCancelOutboundSend(chatItem) {
+ if (!chatItem?.is_outbound || !chatItem.lxmf_message?.hash) {
+ return false;
+ }
+ if (this._isPendingOutboundHash(chatItem.lxmf_message.hash)) {
+ return true;
+ }
+ return this.isOutboundPendingForUi(chatItem);
+ },
isOutboundSendEscalated(chatItem) {
const m = chatItem?.lxmf_message;
if (!chatItem?.is_outbound || !m) {
@@ -5323,6 +5350,12 @@ export default {
replyQuotedContent,
myLxmfAddressHash: this.myLxmfAddressHash,
canOptimisticPending,
+ cancelKey: this._outboundPendingMatchKey({
+ destination_hash: destinationHash,
+ content: text,
+ reply_to_hash: replyToHash,
+ fields,
+ }),
};
},
async sendMessage() {
@@ -5357,6 +5390,9 @@ export default {
},
async _executeOutboundSendJob(job) {
try {
+ if (job.cancelled) {
+ return;
+ }
job.pendingHash = null;
if (job.canOptimisticPending) {
const pendingHash = `pending-${uuidv4()}`;
@@ -5396,6 +5432,11 @@ export default {
});
}
+ if (job.cancelled) {
+ this.removePendingOutboundPlaceholder(job.pendingHash);
+ return;
+ }
+
if (job.images.length === 0) {
const response = await window.api.post(`/api/v1/lxmf-messages/send`, {
delivery_method: job.deliveryMethod,
@@ -5408,6 +5449,12 @@ export default {
},
});
+ if (job.cancelled) {
+ await this._cancelOutboundByHash(response.data.lxmf_message.hash);
+ this.removePendingOutboundPlaceholder(job.pendingHash);
+ return;
+ }
+ job.messageHash = response.data.lxmf_message.hash;
this._absorbOutboundSendResponse(job, response.data.lxmf_message);
} else {
const firstImage = job.images[0];
@@ -5427,6 +5474,12 @@ export default {
},
});
+ if (job.cancelled) {
+ await this._cancelOutboundByHash(response.data.lxmf_message.hash);
+ this.removePendingOutboundPlaceholder(job.pendingHash);
+ return;
+ }
+ job.messageHash = response.data.lxmf_message.hash;
this._absorbOutboundSendResponse(job, response.data.lxmf_message);
for (let i = 1; i < job.images.length; i++) {
@@ -5468,27 +5521,53 @@ export default {
console.log(e);
}
},
+ async _cancelOutboundByHash(messageHash) {
+ if (!messageHash || this._isPendingOutboundHash(messageHash)) {
+ return;
+ }
+ try {
+ const response = await window.api.post(`/api/v1/lxmf-messages/${messageHash}/cancel`);
+ const lxmfMessage = response.data.lxmf_message;
+ if (lxmfMessage) {
+ this.onLxmfMessageUpdated(lxmfMessage);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
async cancelSendingMessage(chatItem) {
- // get lxmf message hash else do nothing
- const lxmfMessageHash = chatItem.lxmf_message.hash;
+ const lxmfMessage = chatItem?.lxmf_message;
+ const lxmfMessageHash = lxmfMessage?.hash;
if (!lxmfMessageHash) {
return;
}
+ chatItem.is_actions_expanded = false;
+ this.messageContextMenu.show = false;
+
+ const cancelMatch = {
+ pendingHash: this._isPendingOutboundHash(lxmfMessageHash) ? lxmfMessageHash : null,
+ messageHash: !this._isPendingOutboundHash(lxmfMessageHash) ? lxmfMessageHash : null,
+ cancelKey: this._outboundPendingMatchKey(lxmfMessage),
+ };
+ this._outboundQueue.cancelJob(cancelMatch);
+
+ if (this._isPendingOutboundHash(lxmfMessageHash)) {
+ this.removePendingOutboundPlaceholder(lxmfMessageHash);
+ return;
+ }
+
+ if (!this.canCancelOutboundSend(chatItem)) {
+ return;
+ }
+
try {
- // cancel sending lxmf message
const response = await window.api.post(`/api/v1/lxmf-messages/${lxmfMessageHash}/cancel`);
-
- // get lxmf message from response
- const lxmfMessage = response.data.lxmf_message;
- if (!lxmfMessage) {
- return;
+ const updated = response.data.lxmf_message;
+ if (updated) {
+ this.onLxmfMessageUpdated(updated);
}
-
- // update lxmf message in ui
- this.onLxmfMessageUpdated(lxmfMessage);
} catch (e) {
- // show error
const message = e.response?.data?.message ?? "failed to cancel message";
DialogUtils.alert(message);
console.log(e);
diff --git a/meshchatx/src/frontend/js/outboundSendQueue.js b/meshchatx/src/frontend/js/outboundSendQueue.js
index 3aef5128..38eb37ca 100644
--- a/meshchatx/src/frontend/js/outboundSendQueue.js
+++ b/meshchatx/src/frontend/js/outboundSendQueue.js
@@ -2,9 +2,26 @@
* Serial outbound job queue: one job runs at a time so later messages reuse
* the route established while sending earlier ones to the same peer.
*/
+function jobMatches(job, match) {
+ if (!job || !match) {
+ return false;
+ }
+ if (match.pendingHash && job.pendingHash === match.pendingHash) {
+ return true;
+ }
+ if (match.messageHash && job.messageHash === match.messageHash) {
+ return true;
+ }
+ if (match.cancelKey && job.cancelKey === match.cancelKey) {
+ return true;
+ }
+ return false;
+}
+
export function createOutboundQueue(processJob) {
const queue = [];
let running = false;
+ let currentJob = null;
async function run() {
if (running) {
@@ -14,10 +31,21 @@ export function createOutboundQueue(processJob) {
try {
while (queue.length) {
const job = queue.shift();
- await processJob(job);
+ if (job?.cancelled) {
+ continue;
+ }
+ currentJob = job;
+ try {
+ await processJob(job);
+ } finally {
+ if (currentJob === job) {
+ currentJob = null;
+ }
+ }
}
} finally {
running = false;
+ currentJob = null;
}
}
@@ -26,6 +54,16 @@ export function createOutboundQueue(processJob) {
queue.push(job);
void run();
},
+ cancelJob(match) {
+ if (currentJob && jobMatches(currentJob, match)) {
+ currentJob.cancelled = true;
+ }
+ for (const job of queue) {
+ if (jobMatches(job, match)) {
+ job.cancelled = true;
+ }
+ }
+ },
get length() {
return queue.length;
},
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index a900e043..b6ec05fa 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1466,6 +1466,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Nachrichtenaktionen",
"save_image_to_device": "Bild auf Gerät speichern",
+ "cancel_send": "Senden abbrechen",
"react": "Reagieren",
"reaction_you": "Du",
"reaction_send_failed": "Reaktion konnte nicht gesendet werden",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 90033575..b246b3a5 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Message actions",
"save_image_to_device": "Save image to device",
+ "cancel_send": "Cancel send",
"react": "React",
"reaction_you": "You",
"reaction_send_failed": "Could not send reaction",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 4f493980..8cdf6d94 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Acciones de mensajes",
"save_image_to_device": "Guardar imagen en el dispositivo",
+ "cancel_send": "Cancelar envío",
"react": "Reacción",
"reaction_you": "Tú.",
"reaction_send_failed": "No podría enviar reacción",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 435e47df..b53b81a3 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Message actions",
"save_image_to_device": "Tallenna kuva laitteelle",
+ "cancel_send": "Peruuta lähetys",
"react": "React",
"reaction_you": "You",
"reaction_send_failed": "Could not send reaction",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 70e9840e..3f23dc52 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Actions du message",
"save_image_to_device": "Enregistrer l'image sur l'appareil",
+ "cancel_send": "Annuler l'envoi",
"react": "Réagir",
"reaction_you": "Toi",
"reaction_send_failed": "Impossible d'envoyer la réaction",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index a1fa2e0b..9ec292cd 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1466,6 +1466,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Azioni messaggio",
"save_image_to_device": "Salva immagine sul dispositivo",
+ "cancel_send": "Annulla invio",
"react": "Reagisci",
"reaction_you": "Tu",
"reaction_send_failed": "Impossibile inviare la reazione",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index c12bf725..035ac64f 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Berichtacties",
"save_image_to_device": "Afbeelding opslaan op apparaat",
+ "cancel_send": "Verzending annuleren",
"react": "Reageren",
"reaction_you": "Jij",
"reaction_send_failed": "Kon reactie niet versturen",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 34e9ff67..7097c8d3 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1466,6 +1466,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "Действия с сообщением",
"save_image_to_device": "Сохранить изображение на устройство",
+ "cancel_send": "Отменить отправку",
"react": "Реакции",
"reaction_you": "Вы",
"reaction_send_failed": "Не удалось отправить реакцию",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 6a03b360..e18f1b81 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1414,6 +1414,7 @@
"forward_open_party_is_self": "That is your own address.",
"message_actions": "消息操作",
"save_image_to_device": "保存图片到设备",
+ "cancel_send": "取消发送",
"react": "反应",
"reaction_you": "您",
"reaction_send_failed": "无法发送反应",
diff --git a/tests/frontend/outboundSendQueue.test.js b/tests/frontend/outboundSendQueue.test.js
index 698b9590..53f0ee66 100644
--- a/tests/frontend/outboundSendQueue.test.js
+++ b/tests/frontend/outboundSendQueue.test.js
@@ -32,4 +32,33 @@ describe("outboundSendQueue", () => {
await new Promise((r) => setTimeout(r, 40));
expect(maxConcurrent).toBe(1);
});
+
+ it("skips cancelled queued jobs and in-flight job after cancel flag", async () => {
+ const order = [];
+ let releaseFirst;
+ const firstGate = new Promise((r) => {
+ releaseFirst = r;
+ });
+ const processJob = vi.fn(async (job) => {
+ order.push(`start:${job.id}`);
+ if (job.id === "a") {
+ await firstGate;
+ }
+ if (job.cancelled) {
+ order.push(`skip:${job.id}`);
+ return;
+ }
+ order.push(`end:${job.id}`);
+ });
+ const q = createOutboundQueue(processJob);
+ q.enqueue({ id: "a" });
+ q.enqueue({ id: "b", cancelKey: "peer|reply|hello|" });
+ await new Promise((r) => setTimeout(r, 5));
+ q.cancelJob({ cancelKey: "peer|reply|hello|" });
+ q.cancelJob({ pendingHash: "pending-a" });
+ releaseFirst();
+ await new Promise((r) => setTimeout(r, 20));
+ expect(order).toEqual(["start:a", "end:a"]);
+ expect(processJob).toHaveBeenCalledTimes(1);
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────